React Compiler 原理:它凭什么能替你写 useMemo
2026-06-17 23:10
我们写了好几年的
useMemo/useCallback,本质上是在替编译器做一件它本该自动完成的事。React Compiler 想把这件事彻底收回去——它的野心不是“帮你优化”,而是让你忘记优化这回事(它的早期代号就叫 Forget)。
之前我写过一篇开了 React Compiler 之后的实战记录,讲打开 Compiler 后遇到的几个 ESLint 报错。那篇偏“踩坑”,这篇我想把原理讲透:React Compiler 到底怎么工作的、它凭什么敢替你做记忆化、它的边界在哪、和 Signals 那套又有什么本质区别。
先回到那个老问题:我们为什么要手写记忆化
要理解 Compiler,得先理解它要解决的痛点。这事其实和 React 的更新模型一脉相承。
React 的渲染是纯函数式的:状态一变,组件函数就从头到尾重新执行一遍,产出新的 UI 描述,再去和上一棵树 diff。这套模型简单可预测,但有个副作用——每次渲染,函数体里的对象、函数、派生计算全都重新生成一遍。
JSXfunction List({ items }) { // 每次渲染都是一个全新的数组引用 const sorted = items.slice().sort(cmp); // 每次渲染都是一个全新的函数引用 const onClick = () => doSomething(); return <Child data={sorted} onClick={onClick} />; }
大多数时候这无所谓。但只要 Child 用 React.memo 包了,或者 sorted / onClick 进了某个 useEffect 的依赖数组,“每次都是新引用”就会击穿缓存、触发本可避免的重渲染或 effect 重跑。
于是我们手动兜底:
JSXconst sorted = useMemo(() => items.slice().sort(cmp), [items]); const onClick = useCallback(() => doSomething(), []);
这套写法有三个老毛病:
- 机械:你在替机器做"输入变没变"的判断。
- 易错:依赖数组写漏一个,就是一个潜伏的 bug。
- 传染:为了让
memo生效,你得把整条链路上的值全包一遍,否则中间断一环就白费。
React Compiler 的目标就一句话:把这三件事全自动化。
它怎么做到的:自动生成一个缓存
React Compiler 是一个编译期工具,跑在 Babel/SWC 阶段。它读你的组件源码,做静态分析,然后改写成带缓存的等价代码。
核心机制可以这么理解:编译器给每个组件分配一个缓存槽数组,把渲染过程中“可能重复创建的值”都存进去,每次渲染先比较输入有没有变,没变就直接复用上一次的结果。
拿上面那段举例,编译后概念上长这样(真实产物来自 react/compiler-runtime,这里简化):
JSXimport { c as _c } from "react/compiler-runtime"; function List({ items }) { const $ = _c(4); // 申请 4 个缓存槽 // sorted:只有 items 变了才重算 let sorted; if ($[0] !== items) { sorted = items.slice().sort(cmp); $[0] = items; $[1] = sorted; } else { sorted = $[1]; } // onClick:没有依赖,只在首次创建 let onClick; if ($[2] === Symbol.for("react.memo_cache_sentinel")) { onClick = () => doSomething(); $[2] = onClick; } else { onClick = $[2]; } // 连 JSX 本身也缓存:sorted / onClick 都没变就复用整个 element let t; if ($[3] !== sorted || /* ... */) { t = <Child data={sorted} onClick={onClick} />; $[3] = sorted; } else { t = /* 上次的 element */; } return t; }
这里有两个关键点,是它比人手写更强的地方:
- 粒度更细:它不只缓存
useMemo那种"大块计算",连单个表达式、甚至 JSX element 本身都能独立缓存。人手写不可能做到这么细——你不会真的去useMemo一个<Child />。 - 依赖自动推导:依赖数组是它算出来的,不是你写的,所以不会漏、不会错。
换句话说,Compiler 做的是一种自动、细粒度、不会写错的 useMemo。这就是它敢说"以后别手写 memo"的底气。
它的前提:Rules of React
但天下没有免费的优化。编译器敢缓存一个值,是因为它假设这个值在输入不变时就不会变——也就是假设你的代码是纯的。
这套假设被官方整理成了 Rules of React,核心几条:
- 组件和 Hook 必须是纯函数:相同输入产出相同结果。
- 渲染期不能有副作用:不能在渲染过程中 mutate props、state,或任何已经渲染用过的值。
- props 和 state 视为不可变。
- Hook 的调用顺序固定,不能放进条件/循环。
为什么这些规则突然变"硬"了?因为在没有 Compiler 时,违反它们往往只是"偶尔出 bug";但有了 Compiler,它会基于这些假设去缓存——你一旦在渲染期偷偷改了某个值,缓存就和真实状态对不上,UI 就会错乱。
违规时会发生什么:Bailout,而不是报错崩溃
这是面试里很容易答错的一个点。
React Compiler 不是那种"你写错就编译失败"的强约束工具。它的策略是保守的、安全第一的:
当它分析一个组件,发现没法确定这段代码是否安全(比如你违反了 Rules of React,或者写了它看不懂的可变操作),它不会硬优化,而是直接放弃这个组件(bail out),原样输出未优化的代码。
也就是说,最坏情况只是"这个组件没被优化",而不是"代码跑挂了"。这是它能在大型存量项目里渐进接入的关键——优化不了的地方,退化成跟以前一样而已。
我在实战篇里遇到的 preserve-manual-memoization 报错就是这个机制的体现:我手写的 useMemo 它没法安全复刻,于是它跳过了整个组件,并通过 ESLint 提醒我"这里我放弃优化了"。配套的 eslint-plugin-react-hooks(v6)就是用来在编码阶段就把这些"会导致 bailout 或语义错误"的写法标出来。
它替代什么,又不做什么
替代(接入后这三件套基本可以不写了):
useMemouseCallbackReact.memo
不替代 / 不做(这点尤其要清楚,容易被追问):
- 它不会帮你修错误的副作用。像"用 effect 同步派生状态"这种反模式,它的态度是报错让你改,而不是默默帮你改对。
- 它不接管
useEffect的语义——副作用还是你的责任。 - 它不改变 React 的渲染模型:组件该重渲染还是重渲染(见 Fiber),它只是让"重渲染时少做无用功"。
记住这条边界:Compiler 优化的是"重渲染时的计算与引用稳定性",不是"要不要重渲染"本身。
渐进接入与逃生舱
它被设计成可以一点点接入:
- 按目录/文件开启:可以只对部分路径启用编译。
"use no memo"指令:在某个组件顶部加这行字符串指令,就能让 Compiler 跳过它——遇到疑似被 Compiler 改出问题的组件时,这是排查的第一手段。- 和手写 memo 共存:存量的
useMemo不用急着删,Compiler 会尝试保留(保留不了才报preserve-manual-memoization)。
在 Expo / React Native 里,开启方式就是 app.json 里的 experiments.reactCompiler: true;在普通 React 项目则是配 Babel 插件 babel-plugin-react-compiler。
横向对比:编译时 vs 运行时 vs Signals
这是最能体现理解深度的部分,也是面试的拔高题。
React Compiler:重渲染 + 自动缓存
React 的世界观始终是"状态变 → 组件函数重跑 → diff → 提交"。Compiler 没有改变这个世界观,它只是在"组件函数重跑"这一步里,把不必要的重复计算缓存掉。本质上还是粗粒度的重渲染 + 缓存优化。
Svelte / Solid 的预编译:把框架"编译没了"
Svelte、Solid 这类也是编译时方案,但路子更激进:它们在编译期就把"状态和 DOM 的对应关系"分析出来,生成直接操作 DOM 的命令式代码,运行时几乎没有"虚拟 DOM + diff"这一层。所以它们没有"组件整体重渲染"的概念。
Signals:细粒度响应式,根本不重渲染
Vue、Solid、Preact Signals、新版 Angular 用的是信号量(Signals)——一种细粒度响应式。它的思路和 React 完全相反:
- React(含 Compiler):状态变 → 整个组件函数重跑 → 用缓存跳过没变的部分。
- Signals:状态变 → 只通知真正依赖它的那几个 DOM 节点更新 → 组件函数根本不重跑。
一句话概括这个分野:
React Compiler 是"让重渲染变便宜",Signals 是"压根不重渲染"。 一个在优化既有模型,一个在换一套模型。
理解了这点,你就能回答"React 为什么不直接上 Signals"——因为那意味着推翻 Fiber 那套可中断、可调度的并发模型(这正是我在 React 更新模型与调度里聊过的:React 的工程核心是"调度任务",而不是"拦截数据")。Compiler 是在不动摇这个根基的前提下,把开发者从手动优化里解放出来。
现状
- React Compiler 随 React 19 趋于稳定,配套
eslint-plugin-react-hooksv6 提供编码期校验。 - 它是可选的:不开,React 一切照旧;开了,则要求代码更严格地遵守 Rules of React。
- 生态(Next.js、Expo 等)都已提供一键开关。
小结
| 维度 | React Compiler |
|---|---|
| 本质 | 编译期自动生成细粒度记忆化代码 |
| 替代 | useMemo / useCallback / React.memo |
| 前提 | 代码遵守 Rules of React(纯、不可变、无渲染期副作用) |
| 违规后果 | bail out 跳过该组件,不崩溃,最多不优化 |
| 不做的事 | 不修副作用、不改渲染模型、不决定"要不要重渲染" |
| vs Signals | 让重渲染变便宜 ≠ 压根不重渲染 |
回到开头那句:它的代号是 Forget。最理想的状态,是你写组件时完全忘记"性能优化"这件事,只管把逻辑写成干净的纯函数——剩下的,编译器替你记得。
配套的实战踩坑见这一篇。